AWS STSの一時認証情報を利用してGASからS3のcsvに対してデータの読み書きをしてみた
データアナリティクス事業本部のueharaです。
今回は、AWS STSの一時認証情報を利用してGASからS3のcsvに対してデータの読み書きをしてみたいと思います。
はじめに
S3とGoogleスプレッドシート間でデータをやりとりする手段として、以下のようにGAS (Google Apps Script)を利用することが考えられます。
ただし、GASからS3(もといAWSサービス)に接続するためにはアクセスキーの情報が必要であり、その管理をどうするかについては考えなければなりません。
そこで、今回はAWS STSの一時認証情報を利用したいと思います。
GASを利用する上でのアクセスキーの管理について
GASを利用する上で、アクセスキーを利用する方法としては大きくは以下の3つがあると考えられます。
- スクリプトにベタ書きする
- プロパティストア(スクリプトプロパティ)に保存する
- スクリプト実行時に何らかの方法でアクセスキーを渡す
まず、1のようにアクセスキーをスクリプトにハードコーディングする方法はセキュリティリスク的に好ましくありません。
2のスクリプトプロパティは良く使われている方法かと思いますし、こちらでも良いと思いますがプロジェクト内のユーザーで編集権限があれば誰でも閲覧・編集することができます。
したがって、スプレッドシートの編集権限とアクセスキーの秘匿性を切り離して考えることができません。
3の方法は例えばスクリプト実行時にユーザーにアクセスキーの入力を要求し、入力されたアクセスキーを利用して処理するという方法が考えられます。
もちろん1や2の方法として利便性は下がりますが、スクリプト側でアクセスキーを保管することは無いので権限管理に頭を悩ませる必要はなくなります。
以下では、3の方法で特に AWS STSの一時認証情報 を利用したいと思います。
GASからS3に接続するためのライブラリ
AWS公式からは、GASからS3に接続するためのライブラリは提供されていません。
冒頭で紹介したブログもそうですが、観測範囲では以下を利用しているケースが多いように見えます。
ただし、今回やりたいことに対しこちらのライブラリでは以下の課題があります。
- 一時認証情報の利用(セッショントークンの利用)に対応していない
- SSLに対応していない(S3のポリシーで暗号化接続以外を拒否する設定になっている場合は接続ができない)
したがって、今回は上記ライブラリを一部改修して利用したいと思います。
リソースの準備
IAMロールの作成
まず、S3バケットは既に作成されていることを想定します。私は cm-da-uehara
というバケットを利用します。
また、対象のS3バケットに対して読み書きの許可をする以下ポリシーを持つIAMロール uehara-s3-test-role
を用意しました。
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "S3Policy",
"Effect": "Allow",
"Action": [
"s3:List*",
"s3:Get*",
"s3:PutObject"
],
"Resource": [
"arn:aws:s3:::cm-da-uehara",
"arn:aws:s3:::cm-da-uehara/*"
]
}
]
}
信頼関係で、普段使っているロールから上記のロールにAssumeする権限を付与しておきます。
こちらはご自身の環境にあわせて設定して下さい。
S3ライブラリの改修
コードは以下のGitHubにアップロードしています。
要点だけ記載すると、主な変更点はセッショントークンへの対応とリクエストにおけるhttpsの利用です。
まず、元のソースではアクセスキーIDとシークレットアクセスキーしか渡せない仕様になっていたので、追加でセッショントークンを渡せるようにしました。
セッショントークンが渡されると、以下のように x-amz-security-token
ヘッダーを追加するようにしています。(参考)
// セッショントークンがある場合、x-amz-security-token ヘッダーを追加
if (this.service.sessionToken) {
amzHeaders.push("x-amz-security-token:" + this.service.sessionToken);
}
また、リクエストは全て https
で送信するようにしています。
メインとなるGASスクリプトの作成
メインとなるGASスクリプトは以下のようにしてみました。
function showAWSCredentialsDialog() {
var html = HtmlService.createHtmlOutputFromFile('AWSCredentialsDialog')
.setWidth(600)
.setHeight(400);
SpreadsheetApp.getUi().showModalDialog(html, 'AWS Credentials');
}
function getS3File(s3, bucketName, objectKey) {
// バケット名とファイル名を指定してファイルの中身を取得
const data = s3.getObject(bucketName, objectKey);
return data;
}
function exportDataToSpreadSheet(data) {
const sheet = SpreadsheetApp.getActiveSheet();
sheet.clear()
sheet.getRange(1, 1, data.length, data[0].length).setValues(data);
}
function readTable(accessKeyId, secretAccessKey, sessionToken) {
try {
const s3 = getInstance(accessKeyId, secretAccessKey, sessionToken);
const bucketName = '<YOUR BUCKET NAME>';
const objectKey = '<YOUR OBJECT KEY>';
const data = getS3File(s3, bucketName, objectKey);
const parsedData = Utilities.parseCsv(data.getDataAsString());
// 読み込んだデータを展開
exportDataToSpreadSheet(parsedData);
return 'ファイルの読み込みが完了しました';
} catch (error) {
Logger.log('Error in readTable: ' + error.toString());
throw new Error('ファイルの読み込み中にエラーが発生しました: ' + error.message);
}
}
function putS3File(s3, bucketName, objectKey) {
const sheet = SpreadsheetApp.getActiveSheet();
// データが存在する範囲を取得
const dataRange = sheet.getDataRange();
const data = dataRange.getValues();
// 送信データ用の配列を用意
let csv = '';
// データをチェックしながらループ
for (let i = 0; i < data.length; i++) {
const row = data[i];
// 行に少なくとも1つの非空セルがある場合
if (row.some(cell => cell !== '')) {
// データを作成(すべての列を含める)
csv += row.map(cell => `"${cell}"`).join(',') + "\n";
}
}
// バイナリに変換
const csvBlob = Utilities.newBlob(csv, 'text/csv');
s3.putObject(bucketName, objectKey, csvBlob);
}
function writeTable(accessKeyId, secretAccessKey, sessionToken) {
try {
const s3 = getInstance(accessKeyId, secretAccessKey, sessionToken);
const bucketName = '<YOUR BUCKET NAME>';
const objectKey = '<YOUR OBJECT KEY>';
putS3File(s3, bucketName, objectKey)
return 'ファイルの書き込みが完了しました。';
} catch (error) {
Logger.log('Error in writeTable: ' + error.toString());
throw new Error('ファイルの書き込み中にエラーが発生しました: ' + error.message);
}
}
S3に保存しているcsvファイルのバケット名、オブジェクトキーを定義することで、スプレッドシートでGet/Putできるようにしています。
私の手元ではバケット名を cm-da-uehara
、オブジェクトキーを data.csv
で記載をしました。
showAWSCredentialsDialog()
関数は次に記載する認証情報入力フォームを描画する関数です。
認証情報入力フォームの用意
認証情報を取得する入力フォームのダイアログは以下のようにしました。
<!DOCTYPE html>
<html>
<head>
<base target="_top">
<style>
body { font-family: Arial, sans-serif; }
.form-group { margin-bottom: 10px; }
label { display: block; margin-bottom: 5px; }
textarea { width: 100%; padding: 5px; box-sizing: border-box; height: 150px; }
.button-container { text-align: right; margin-top: 15px; }
button { padding: 5px 10px; margin-left: 10px; }
</style>
</head>
<body>
<div class="form-group">
<label for="awsSecrets">AWS 認証情報 (AccessKeyId:SecretAccessKey:SessionToken):</label>
<textarea id="awsSecrets" required></textarea>
</div>
<div class="button-container">
<button onclick="readFile()">ファイルを読み込む</button>
<button onclick="writeFile()">ファイルを書き込む</button>
</div>
<script>
function getCredentials() {
var awsSecrets = document.getElementById('awsSecrets').value.trim();
if (!awsSecrets) {
alert('AWS Secretsを入力してください。');
return null;
}
var parts = awsSecrets.split(':');
if (parts.length !== 3) {
alert('AWS Secretsの形式が正しくありません。AccessKeyId:SecretAccessKey:SessionTokenの形式で入力してください。');
return null;
}
return { accessKeyId: parts[0], secretAccessKey: parts[1], sessionToken: parts[2] };
}
function readFile() {
var credentials = getCredentials();
if (credentials) {
google.script.run
.withSuccessHandler(onSuccess)
.withFailureHandler(onFailure)
.readTable(credentials.accessKeyId, credentials.secretAccessKey, credentials.sessionToken);
}
}
function writeFile() {
var credentials = getCredentials();
if (credentials) {
google.script.run
.withSuccessHandler(onSuccess)
.withFailureHandler(onFailure)
.writeTable(credentials.accessKeyId, credentials.secretAccessKey, credentials.sessionToken);
}
}
function onSuccess(result) {
alert('操作が成功しました: ' + result);
google.script.host.close();
}
function onFailure(error) {
alert('エラーが発生しました: ' + error.message);
}
</script>
</body>
</html>
認証情報としてはアクセスキーID、シークレットアクセスキー、セッショントークンをセミコロンで結合した値を期待しています。ここで利用する認証情報の値は後述するShellスクリプトから取得します。
入力された値はパースを行い、S3上のデータの読み込み/書き込みを行う関数に渡す形としています。
GASスクリプトのデプロイ
ここまで準備できたらスプレッドシートにGASスクリプトをデプロイします。
スプレッドシートのメニューバーにある「拡張機能」から「Apps Script」を選択し、以下のようにスクリプトを配置します。
次にスプレッドシート側に適当にボタンを用意し、スクリプトの割り当てから showAWSCredentialsDialog
関数を割り当てます。
以上でスプレッドシート側の準備は完了です。
認証情報の取得スクリプト
GAS側に渡す認証情報を発行するスクリプトを以下のように作成してみました。
#!/usr/bin/env bash
set -euo pipefail
# 引数チェック
if [ $# -lt 1 ]; then
echo "指定された引数は$#個です。" 1>&2
echo "実行するには1個の引数が必要です。" 1>&2
echo "$0 [IAM ROLE NAME]"
exit 1
fi
ROLE_NAME=$1
export AWS_SDK_LOAD_CONFIG=true
ROLE_ARN=$(aws iam get-role --role-name ${ROLE_NAME} --query 'Role.Arn' --output text)
STS_RESULT=$(aws sts assume-role --role-arn ${ROLE_ARN} --role-session-name s3-upload-session --duration-seconds 900)
AWS_ACCESS_KEY_ID=$(echo ${STS_RESULT} | jq -r '.Credentials.AccessKeyId')
AWS_SECRET_ACCESS_KEY=$(echo ${STS_RESULT} | jq -r '.Credentials.SecretAccessKey')
SESSION_TOKEN=$(echo ${STS_RESULT} | jq -r '.Credentials.SessionToken')
echo "以下の認証情報 (AccessKeyId:SecretAccessKey:SessionToken) をGASのフォームに貼り付けて下さい。"
echo "${AWS_ACCESS_KEY_ID}:${AWS_SECRET_ACCESS_KEY}:${SESSION_TOKEN}"
こちらは bash get_secrets.sh uehara-s3-test-role
のような感じで先に作成したS3アクセス用のロールを指定するとAWS STSを利用して一時認証情報を発行するスクリプトになっています。
発行した認証情報は15分間有効です。
やってみた
まず、S3の cm-da-uehara
バケットに以下のような data.csv
ファイルを配置します。
id,name,score
1001,Tanaka,92
1002,Hashimoto,50
1003,Ueno,27
リソースの準備で作成したスプレッドシート中の「S3に接続」ボタンを押下すると、次のように認証情報を入力するフォームが表示されると思います。
認証情報を取得するために、 get_secrets.sh
を実行します。
$ bash get_secrets.sh
以下の認証情報 (AccessKeyId:SecretAccessKey:SessionToken) をGASのフォームに貼り付けて下さい。
ASIAZZKEQYZ7...
取得した認証情報をフォームに入力し、「ファイルを読み込む」ボタンを押下します。
正常に読み込みが完了すると、以下のようにS3に配置したcsvファイルのデータが読み込まれていることを確認できます。
同じ要領で、スプレッドシートに値を追加し、書き出しを行ってみます。
S3に保存されている data.csv
を確認すると、追加したデータが反映されていることが分かります。
最後に
今回は、AWS STSの一時認証情報を利用してGASからS3のcsvに対してデータの読み書きをしてみました。
参考になりましたら幸いです。